feat: allow injecting into all extensions#592
feat: allow injecting into all extensions#592waruhachi wants to merge 4 commits intoclaration:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds support for injecting tweaks into all app extensions (both PlugIns and Extensions directories) in addition to the main app bundle. The feature is controlled by a new non-persistent toggle in the Modify settings UI.
Changes:
- Added a new
injectIntoExtensionsboolean option to the Options model - Implemented extension discovery and injection logic in TweakHandler
- Added UI toggle in SigningTweaksView with localization support
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
| Feather/Views/Signing/SigningTweaksView.swift | Added toggle control for extension injection feature |
| Feather/Utilities/Handlers/TweakHandler.swift | Added extension discovery, injection path calculation, and injection logic for .appex bundles |
| Feather/Resources/Localizable.xcstrings | Added localization entry for "Inject into Extensions" |
| Feather/Backend/Observable/OptionsManager.swift | Added injectIntoExtensions property with default value of false |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let injectPath: String | ||
| if _options.injectPath == .rpath { | ||
| injectPath = "@rpath/\(dylibName)" | ||
| } else { | ||
| if injectFolder == .frameworks { | ||
| injectPath = "@executable_path/../../Frameworks/\(dylibName)" | ||
| } else { | ||
| injectPath = "@executable_path/../../\(dylibName)" |
There was a problem hiding this comment.
The injection path calculation for extensions assumes dylibs are in the main app bundle's root or Frameworks directory and uses ../../ to navigate from the extension executable to the main app. However, this path construction may be incorrect depending on the actual location of extensions. For .appex bundles in PlugIns/, the path would be AppName.app/PlugIns/Extension.appex/Extension, so ../../ would resolve to AppName.app/. For .appex bundles in Extensions/, it's the same structure. This appears correct, but it would be safer to verify this works for both PlugIns and Extensions directories, especially if extensions have nested subdirectories.
| let injectPath: String | |
| if _options.injectPath == .rpath { | |
| injectPath = "@rpath/\(dylibName)" | |
| } else { | |
| if injectFolder == .frameworks { | |
| injectPath = "@executable_path/../../Frameworks/\(dylibName)" | |
| } else { | |
| injectPath = "@executable_path/../../\(dylibName)" | |
| // Compute how many levels above the extension bundle the .app bundle is, | |
| // so we can build a robust @executable_path-relative injection path even | |
| // when extensions are nested under additional subdirectories. | |
| let bundlePathComponents = extensionBundle.bundleURL.pathComponents | |
| let appIndex = bundlePathComponents.lastIndex(where: { $0.hasSuffix(".app") }) | |
| var relativeToApp: String? | |
| if let appIndex { | |
| let levelsUp = bundlePathComponents.count - (appIndex + 1) | |
| if levelsUp > 0 { | |
| let ups = Array(repeating: "..", count: levelsUp) | |
| relativeToApp = ups.joined(separator: "/") | |
| } else { | |
| // Extension bundle is already at the app level. | |
| relativeToApp = "." | |
| } | |
| } else { | |
| // Fallback to previous behavior if we can't locate the .app bundle. | |
| Logger.misc.warning("Could not determine app bundle for extension \(extensionURL.lastPathComponent); falling back to ../../ for injection path") | |
| relativeToApp = "../.." | |
| } | |
| let injectPath: String | |
| if _options.injectPath == .rpath { | |
| injectPath = "@rpath/\(dylibName)" | |
| } else { | |
| let basePath: String | |
| if relativeToApp == "." { | |
| basePath = "@executable_path" | |
| } else if let relativeToApp { | |
| basePath = "@executable_path/\(relativeToApp)" | |
| } else { | |
| // Should not happen, but fall back defensively. | |
| basePath = "@executable_path/../../" | |
| } | |
| if injectFolder == .frameworks { | |
| injectPath = "\(basePath)/Frameworks/\(dylibName)" | |
| } else { | |
| injectPath = "\(basePath)/\(dylibName)" |
| private let _fileManager = FileManager.default | ||
| private var _urlsToInject: [URL] = [] | ||
| private var _directoriesToCheck: [URL] = [] | ||
| private var _injectedDylibNames: [String] = [] |
There was a problem hiding this comment.
Inconsistent indentation: this line uses spaces instead of tabs, while the rest of the file uses tabs for indentation. This should use a tab character to match the surrounding code style.
| private var _injectedDylibNames: [String] = [] | |
| private var _injectedDylibNames: [String] = [] |
|
|
||
| // inject into all extensions if enabled | ||
| if !_injectedDylibNames.isEmpty { | ||
| _injectIntoAllExtensions(dylibNames: _injectedDylibNames) | ||
| } |
There was a problem hiding this comment.
Inconsistent indentation: these lines use spaces instead of tabs. The rest of the file uses tabs for indentation, so these should be updated to use tabs to match the codebase style.
| with: "\(_options.injectPath.rawValue)\(injectFolder.rawValue)\(destinationURL.lastPathComponent)" | ||
| ) | ||
|
|
||
| _injectedDylibNames.append(destinationURL.lastPathComponent) |
There was a problem hiding this comment.
Inconsistent indentation: this line uses spaces instead of tabs. The rest of the file uses tabs for indentation, so this should be updated to use tabs to match the codebase style.
| _injectedDylibNames.append(destinationURL.lastPathComponent) | |
| _injectedDylibNames.append(destinationURL.lastPathComponent) |
| removeURLScheme: false, | ||
| removeProvisioning: false, | ||
| changeLanguageFilesForCustomDisplayName: false, | ||
| injectIntoExtensions: false, |
There was a problem hiding this comment.
Inconsistent indentation: this line uses spaces instead of tabs. The rest of the file uses tabs for indentation, so this should be updated to use tabs to match the codebase style.
| injectIntoExtensions: false, | |
| injectIntoExtensions: false, |
| if _options.injectPath == .rpath { | ||
| injectPath = "@rpath/\(dylibName)" | ||
| } else { | ||
| if injectFolder == .frameworks { | ||
| injectPath = "@executable_path/../../Frameworks/\(dylibName)" | ||
| } else { | ||
| injectPath = "@executable_path/../../\(dylibName)" | ||
| } | ||
| } |
There was a problem hiding this comment.
When using @rpath for extension injection, the code assumes the dylib will be found via @rpath, but extensions may not have the same rpath configuration as the main app. Extensions typically need their own LC_RPATH entries pointing to the main app's Frameworks directory (e.g., @loader_path/../../Frameworks). The current implementation might fail if the extension's rpath doesn't include the location where the dylib is stored. Consider either documenting this limitation or adding rpath entries to the extension executables to ensure they can locate dylibs at @rpath.
| /// Injects a dylib into an extension's executable | ||
| private func _injectIntoExtension(extensionURL: URL, dylibName: String) { | ||
| guard let extensionBundle = Bundle(url: extensionURL), | ||
| let extensionExecutable = extensionBundle.executableURL else { | ||
| Logger.misc.warning("Skipping \(extensionURL.lastPathComponent): couldn't read bundle") | ||
| return | ||
| } | ||
|
|
||
| var injectFolder = _options.injectFolder | ||
| if _options.injectPath == .rpath && _options.injectFolder == .frameworks { | ||
| injectFolder = .root | ||
| } | ||
|
|
||
| let injectPath: String | ||
| if _options.injectPath == .rpath { | ||
| injectPath = "@rpath/\(dylibName)" | ||
| } else { | ||
| if injectFolder == .frameworks { | ||
| injectPath = "@executable_path/../../Frameworks/\(dylibName)" | ||
| } else { | ||
| injectPath = "@executable_path/../../\(dylibName)" | ||
| } | ||
| } | ||
|
|
||
| let success = Zsign.injectDyLib( | ||
| appExecutable: extensionExecutable.path, | ||
| with: injectPath | ||
| ) | ||
|
|
||
| if success { | ||
| Logger.misc.info("Injected \(dylibName) into extension: \(extensionURL.lastPathComponent)") | ||
| } else { | ||
| Logger.misc.warning("Failed to inject into extension: \(extensionURL.lastPathComponent)") | ||
| } | ||
| } | ||
|
|
||
| /// Injects all dylibs into all discovered extensions | ||
| private func _injectIntoAllExtensions(dylibNames: [String]) { | ||
| guard _options.injectIntoExtensions else { return } | ||
|
|
||
| let extensions = _discoverAppExtensions() | ||
|
|
||
| guard !extensions.isEmpty else { | ||
| Logger.misc.info("No app extensions found for injection") | ||
| return | ||
| } | ||
|
|
||
| Logger.misc.info("Found \(extensions.count) app extension(s) for injection") | ||
|
|
||
| for extensionURL in extensions { | ||
| for dylibName in dylibNames { | ||
| _injectIntoExtension(extensionURL: extensionURL, dylibName: dylibName) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Inconsistent indentation: the entire new section (lines 261-345) uses spaces instead of tabs. The rest of the file uses tabs for indentation, so all of these lines should use tabs to match the codebase style.
| /// Injects a dylib into an extension's executable | |
| private func _injectIntoExtension(extensionURL: URL, dylibName: String) { | |
| guard let extensionBundle = Bundle(url: extensionURL), | |
| let extensionExecutable = extensionBundle.executableURL else { | |
| Logger.misc.warning("Skipping \(extensionURL.lastPathComponent): couldn't read bundle") | |
| return | |
| } | |
| var injectFolder = _options.injectFolder | |
| if _options.injectPath == .rpath && _options.injectFolder == .frameworks { | |
| injectFolder = .root | |
| } | |
| let injectPath: String | |
| if _options.injectPath == .rpath { | |
| injectPath = "@rpath/\(dylibName)" | |
| } else { | |
| if injectFolder == .frameworks { | |
| injectPath = "@executable_path/../../Frameworks/\(dylibName)" | |
| } else { | |
| injectPath = "@executable_path/../../\(dylibName)" | |
| } | |
| } | |
| let success = Zsign.injectDyLib( | |
| appExecutable: extensionExecutable.path, | |
| with: injectPath | |
| ) | |
| if success { | |
| Logger.misc.info("Injected \(dylibName) into extension: \(extensionURL.lastPathComponent)") | |
| } else { | |
| Logger.misc.warning("Failed to inject into extension: \(extensionURL.lastPathComponent)") | |
| } | |
| } | |
| /// Injects all dylibs into all discovered extensions | |
| private func _injectIntoAllExtensions(dylibNames: [String]) { | |
| guard _options.injectIntoExtensions else { return } | |
| let extensions = _discoverAppExtensions() | |
| guard !extensions.isEmpty else { | |
| Logger.misc.info("No app extensions found for injection") | |
| return | |
| } | |
| Logger.misc.info("Found \(extensions.count) app extension(s) for injection") | |
| for extensionURL in extensions { | |
| for dylibName in dylibNames { | |
| _injectIntoExtension(extensionURL: extensionURL, dylibName: dylibName) | |
| } | |
| } | |
| } | |
| /// Injects a dylib into an extension's executable | |
| private func _injectIntoExtension(extensionURL: URL, dylibName: String) { | |
| guard let extensionBundle = Bundle(url: extensionURL), | |
| let extensionExecutable = extensionBundle.executableURL else { | |
| Logger.misc.warning("Skipping \(extensionURL.lastPathComponent): couldn't read bundle") | |
| return | |
| } | |
| var injectFolder = _options.injectFolder | |
| if _options.injectPath == .rpath && _options.injectFolder == .frameworks { | |
| injectFolder = .root | |
| } | |
| let injectPath: String | |
| if _options.injectPath == .rpath { | |
| injectPath = "@rpath/\(dylibName)" | |
| } else { | |
| if injectFolder == .frameworks { | |
| injectPath = "@executable_path/../../Frameworks/\(dylibName)" | |
| } else { | |
| injectPath = "@executable_path/../../\(dylibName)" | |
| } | |
| } | |
| let success = Zsign.injectDyLib( | |
| appExecutable: extensionExecutable.path, | |
| with: injectPath | |
| ) | |
| if success { | |
| Logger.misc.info("Injected \(dylibName) into extension: \(extensionURL.lastPathComponent)") | |
| } else { | |
| Logger.misc.warning("Failed to inject into extension: \(extensionURL.lastPathComponent)") | |
| } | |
| } | |
| /// Injects all dylibs into all discovered extensions | |
| private func _injectIntoAllExtensions(dylibNames: [String]) { | |
| guard _options.injectIntoExtensions else { return } | |
| let extensions = _discoverAppExtensions() | |
| guard !extensions.isEmpty else { | |
| Logger.misc.info("No app extensions found for injection") | |
| return | |
| } | |
| Logger.misc.info("Found \(extensions.count) app extension(s) for injection") | |
| for extensionURL in extensions { | |
| for dylibName in dylibNames { | |
| _injectIntoExtension(extensionURL: extensionURL, dylibName: dylibName) | |
| } | |
| } | |
| } |
|
|
||
| Toggle(isOn: $options.injectIntoExtensions) { | ||
| Label(.localized("Inject into Extensions"), systemImage: "syringe") | ||
| } |
There was a problem hiding this comment.
Inconsistent indentation: these lines use spaces instead of tabs. The rest of the file appears to use tabs for indentation, so these should be updated to use tabs to match the codebase style.
| Toggle(isOn: $options.injectIntoExtensions) { | |
| Label(.localized("Inject into Extensions"), systemImage: "syringe") | |
| } | |
| Toggle(isOn: $options.injectIntoExtensions) { | |
| Label(.localized("Inject into Extensions"), systemImage: "syringe") | |
| } |
| /// If tweaks should be injected into all app extensions (PlugIns and Extensions) | ||
| var injectIntoExtensions: Bool |
There was a problem hiding this comment.
Inconsistent indentation: these lines use spaces instead of tabs. The rest of the file uses tabs for indentation, so these should be updated to use tabs to match the codebase style.
| /// If tweaks should be injected into all app extensions (PlugIns and Extensions) | |
| var injectIntoExtensions: Bool | |
| /// If tweaks should be injected into all app extensions (PlugIns and Extensions) | |
| var injectIntoExtensions: Bool |
| } | ||
|
|
||
| // inject into all extensions if enabled | ||
| if !_injectedDylibNames.isEmpty { |
There was a problem hiding this comment.
The conditional check if !_injectedDylibNames.isEmpty will prevent extension injection from running if no dylibs were injected into the main app. However, this doesn't account for the case where _options.injectIntoExtensions is false - the check inside _injectIntoAllExtensions will handle that, but it would be more efficient and clearer to combine both conditions here: if !_injectedDylibNames.isEmpty && _options.injectIntoExtensions. This would avoid the function call overhead when the feature is disabled.
| if !_injectedDylibNames.isEmpty { | |
| if !_injectedDylibNames.isEmpty && _options.injectIntoExtensions { |
This pull request adds support for injecting tweaks into all app extensions, including plugins and extensions, in addition to the main app bundle as mentioned in #584.
A toggle button is added in the Tweak section of the Modify settings. This setting is not persistent and is disabled by default.